This sets up an agent-based model that exhibits complexity. We will use it to for the following workshop, where you will attempt to optimize over the output from the model.
The model is a square grid of cells, wrapping on both axes, with one agent per cell. Each agent has a value that it can adjust up or down. An agent wants to have a higher value than three of its neighbors, but does not want to be the highest. Agents will:
# From https://stackoverflow.com/questions/2453326/fastest-way-to-find-second-third-highest-lowest-value-in-vector-or-column
maxN <- function(x, N=2){
len <- length(x)
if(N>len){
warning('N greater than length(x). Setting N=length(x)')
N <- length(x)
}
sort(x,partial=len-N+1)[len-N+1]
}
# End sourced code
# Gets the change the agent performs based on its neighbors (not the new value)
getAgentChange <- function(agentValue, neighborValues)
{
neighborValues = c(neighborValues, agentValue)
if (agentValue >= max(neighborValues))
return (-3)
if (agentValue < mean(neighborValues))
return (1)
if (agentValue >= mean(neighborValues) && agentValue < maxN(neighborValues, 2))
return (1)
return (0)
}
# Builds a world with the specified height and width.
# startingValues should be a vector, with one value
# per cell (applied down each column, then across each row).
buildWorld <- function(height, width, startingValues = NULL)
{
if (is.null(startingValues))
{
startingValues = rep(100, height * width)
}
densityValue = 1 / (height * width)
sumStartValues = sum(startingValues)
world <- data.frame(X=0, Y=0, Value=startingValues[1], Density = densityValue)
i = 1
for (x in 1:width)
{
for (y in 1:height)
{
world[i,]$X = x
world[i,]$Y = y
world[i,]$Value = startingValues[i]
world[i,]$Density = startingValues[i] / sumStartValues
i = i + 1
}
}
return (world)
}
# Create and graph an initial world
world = buildWorld(16, 16, seq(100, 125.6, 0.1))
ggplot(world, aes(X, Y, z = Density)) +
geom_raster(aes(fill = Density)) +
geom_contour(colour = "white")
# Updates the passed in world according to the updateFunction
# Neighbors are only the adjacent four. If includeDiagonals is
# true, then all eight adjacent cells are counted as neighbors.
updateWorld <- function(world, updateFunction, includeDiagonals = FALSE)
{
newWorld = buildWorld(max(world$X), max(world$Y))
for (x in 1:max(world$X))
{
xLower = x - 1
if (xLower == 0) xLower = max(world$X)
xUpper = x + 1
if (xUpper > max(world$X)) xUpper = 1
for (y in 1:max(world$Y))
{
yLower = y - 1
if (yLower == 0) yLower = max(world$Y)
yUpper = y + 1
if (yUpper > max(world$Y)) yUpper = 1
neighborValues = c()
neighborValues = c(neighborValues, world[world$X == xUpper & world$Y == y,]$Value)
neighborValues = c(neighborValues, world[world$X == xLower & world$Y == y,]$Value)
neighborValues = c(neighborValues, world[world$X == x & world$Y == yUpper,]$Value)
neighborValues = c(neighborValues, world[world$X == x & world$Y == yLower,]$Value)
if (includeDiagonals)
{
neighborValues = c(neighborValues, world[world$X == xUpper & world$Y == yUpper,]$Value)
neighborValues = c(neighborValues, world[world$X == xUpper & world$Y == yLower,]$Value)
neighborValues = c(neighborValues, world[world$X == xLower & world$Y == yUpper,]$Value)
neighborValues = c(neighborValues, world[world$X == xLower & world$Y == yLower,]$Value)
}
currentValue = world[newWorld$X == x & newWorld$Y == y,]$Value
newWorld[newWorld$X == x & newWorld$Y == y,]$Value = currentValue + updateFunction(currentValue, neighborValues)
}
}
newWorld$Density = newWorld$Value / sum(newWorld$Value)
return (newWorld)
}
# Update the world and display its new landscape
world = updateWorld(world, getAgentChange)
ggplot(world, aes(X, Y, z = Density)) +
geom_raster(aes(fill = Density)) +
geom_contour(colour = "white")
# Assembles the base data - what elevation is each coordinate at for each time step
buildTimeWorld <- function(width, height, iterations)
{
lastStep = buildWorld(width, height, seq(100, 100+(height * width * 0.1), 0.1))
lastStep$Time = rep(1, nrow(lastStep))
totalData = lastStep
for (t in 2:iterations)
{
newData = updateWorld(lastStep, getAgentChange)
newData$Time = rep(t, nrow(newData))
lastStep = newData
totalData = rbind(totalData, newData)
}
return(totalData)
}
# Given a set of points over time, outputs the optimal point at each time step
getOptimalData <- function(timeWorld)
{
optimalData = data.frame(OptimalX = 0, OptimalY = 0, Time = 0)
i = 1
for (t in min(timeWorld$Time):max(timeWorld$Time))
{
timeSubset = timeWorld[timeWorld$Time == t,]
optimalData[i,]$OptimalX = timeSubset[timeSubset$Value == max(timeSubset$Value),][1,]$X
optimalData[i,]$OptimalY = timeSubset[timeSubset$Value == max(timeSubset$Value),][1,]$Y
optimalData[i,]$Time = t
i = i + 1
}
return (optimalData)
}
# Given a set of points over time and an optimization function, returns the optimization function's
# guess of optimality for each time step
getGuesses <- function(timeWorld, optimizeFunction, ticksPerTime = 1, ...)
{
guessData = data.frame(GuessedX = 0, GuessedY = 0, Time = 0)
xGuess = floor(mean(timeWorld$X))
yGuess = floor(mean(timeWorld$Y))
i = 1
for (t in min(timeWorld$Time):max(timeWorld$Time))
{
timeSubset = timeWorld[timeWorld$Time == t,]
guesses = optimizeFunction(timeSubset, ticksPerTime, xGuess, yGuess, ...)
xGuess = guesses[1]
yGuess = guesses[2]
guessData[i,]$GuessedX = xGuess
guessData[i,]$GuessedY = yGuess
guessData[i,]$Time = t
i = i + 1
}
return (guessData)
}
# Encapsulate the work necessary to build a world into a single function.
# Builds a world (or reuses the one passed in as timeWorld). Uses that to obtain optimal points
# (if includeOptimalPoints is true), as well as run an optimization function (if optimizeFunction is not
# null). Returns an object with three dataframes (or NULLs) - worldData, optimalPoints, and guessedPoints.
assembleData <- function (width = 17, height = 17, iterations = 50, timeWorld = NULL, optimizeFunction = NULL, ticksPerTime = 1, includeOptimalPoints = T, ...)
{
finalData <- list(worldData = NULL, optimalPoints = NULL, guessedPoints = NULL)
if (is.null(timeWorld))
{
finalData$worldData = buildTimeWorld(width, height, iterations)
} else {
finalData$worldData = timeWorld
}
if (!is.null(optimizeFunction))
{
finalData$guessedPoints = getGuesses(finalData$worldData, optimizeFunction, ticksPerTime, ...)
}
if (includeOptimalPoints)
{
finalData$optimalPoints = getOptimalData(finalData$worldData)
}
return (finalData)
}
# Assembling a world only
finalData = assembleData(16, 16, 50)
# Save out the world for reuse
timeWorld = finalData$worldData
# See how much faster this is now?
finalData = assembleData(timeWorld = timeWorld)
# Given an object, such as the output of assembleData(), visualizes the output
# Displays the height of each point using a raster (legend at right), animated over time.
# If optimalPoints are included, they are graphed at each time step as a pink dot.
# If guessedPoints are included, they are graphed at each time step as a green dot.
# Be aware that this function may take a while (a few minutes) to run - animations are slow work.
visualizeFunction <- function(allData, fromtime = 25, untiltime = 50)
{
fromtime = max(min(fromtime, max(allData$worldData$Time)), min(allData$worldData$Time))
untiltime = max(min(untiltime, max(allData$worldData$Time)), min(allData$worldData$Time))
worldData = allData$worldData[allData$worldData$Time >= fromtime & allData$worldData$Time <= untiltime,]
# Assemble base animation
animation = ggplot(worldData, aes(X, Y, z = Density)) +
geom_raster(aes(fill = Density)) +
transition_states(Time, transition_length = 10, state_length = 0, wrap=F)
labs(title = "Time: {closest_state}") +
enter_fade() +
exit_fade() +
ease_aes("linear")
# Add optimal points, if present
if (!is.null(allData$optimalPoints))
{
optData = allData$optimalPoints[allData$optimalPoints$Time >= fromtime & allData$optimalPoints$Time <= untiltime,]
lengthenFactor = nrow(worldData) / nrow(optData)
final = optData
for(i in 2:lengthenFactor) final = rbind(final, optData)
optData = final
optData = optData[with(optData, order(Time)), ]
animation = animation + geom_point(x=optData$OptimalX, y=optData$OptimalY, colour="hotpink", size=4)
}
# Add guessed points, if present
if (!is.null(allData$guessedPoints))
{
guessData = allData$guessedPoints[allData$guessedPoints$Time >= fromtime & allData$guessedPoints$Time <= untiltime,]
lengthenFactor = nrow(worldData) / nrow(guessData)
final = guessData
for(i in 2:lengthenFactor) final = rbind(final, guessData)
guessData = final
guessData = guessData[with(guessData, order(Time)), ]
animation = animation + geom_point(x=guessData$GuessedX, y=guessData$GuessedY, colour="lawngreen", size=4)
}
# Display animation
animation
}
visualizeFunction(finalData)
All functions have a standardized signature:
# Returns the average distance between guessed and optimal points
evaluate <- function (allData, fromtime = 25, untiltime = 50)
{
if (is.null(allData$optimalPoints) || is.null(allData$guessedPoints))
{
stop("Incomplete data for evaluate; allData is missing either optimalPoints or guessedPoints")
}
expected = allData$optimalPoints
actual = allData$guessedPoints
fromtime = max(min(fromtime, max(expected$Time)), min(expected$Time))
untiltime = max(min(untiltime, max(expected$Time)), min(expected$Time))
xLength = max(allData$worldData$X) - min(allData$worldData$X) + 1
yLength = max(allData$worldData$Y) - min(allData$worldData$Y) + 1
averageDistance = 0
for (t in fromtime:untiltime)
{
xLower = min(expected[expected$Time == t,]$OptimalX, actual[actual$Time == t,]$GuessedX)
xUpper = max(expected[expected$Time == t,]$OptimalX, actual[actual$Time == t,]$GuessedX)
yLower = min(expected[expected$Time == t,]$OptimalY, actual[actual$Time == t,]$GuessedY)
yUpper = max(expected[expected$Time == t,]$OptimalY, actual[actual$Time == t,]$GuessedY)
baseDistance = sqrt((xUpper - xLower) ^ 2 + (yUpper - yLower) ^ 2)
xWrap = sqrt((xUpper - (xLower + xLength)) ^ 2 + (yUpper - yLower) ^ 2)
yWrap = sqrt((xUpper - xLower) ^ 2 + (yUpper - (yLower + yLength)) ^ 2)
bothWrap = sqrt((xUpper - (xLower + xLength)) ^ 2 + (yUpper - (yLower + yLength)) ^ 2)
averageDistance = averageDistance + min(baseDistance, xWrap, yWrap, bothWrap)
}
averageDistance = averageDistance / (untiltime - fromtime + 1)
return (averageDistance)
}
# Returns accuracy as a percent of maximum possible distance
evaluatePercent <- function (allData, fromtime = 25, untiltime = 50)
{
avgDistance = evaluate(allData, fromtime, untiltime)
xLength = max(allData$worldData$X) - min(allData$worldData$X) + 1
yLength = max(allData$worldData$Y) - min(allData$worldData$Y) + 1
percent = avgDistance / sqrt((xLength / 2) ^ 2 + (yLength / 2) ^ 2)
percent = 1 - percent
return (percent)
}
# Returns percent of measured timesteps where the guessed point is the same as the optimal point
evaluateSyncCount <- function(allData, fromtime = 25, untiltime = 50)
{
if (is.null(allData$optimalPoints) || is.null(allData$guessedPoints))
{
stop("Incomplete data for evaluate; allData is missing either optimalPoints or guessedPoints")
}
expected = allData$optimalPoints
actual = allData$guessedPoints
fromtime = max(min(fromtime, max(expected$Time)), min(expected$Time))
untiltime = max(min(untiltime, max(expected$Time)), min(expected$Time))
syncCount = 0
for (t in fromtime:untiltime)
{
xGuess = actual[actual$Time == t,]$GuessedX
yGuess = actual[actual$Time == t,]$GuessedY
xOptimal = expected[expected$Time == t,]$OptimalX
yOptimal = expected[expected$Time == t,]$OptimalY
if (xGuess == xOptimal && yGuess == yOptimal)
{
syncCount = syncCount + 1
}
}
syncCount = syncCount / (untiltime - fromtime + 1)
return (syncCount)
}
# Randomly selects a point in bounds and returns it.
randomGuesses <- function(currentData, ticks, startX = 1, startY = 1)
{
bestX = round(runif(1, min = min(currentData$X), max = max(currentData$X)))
bestY = round(runif(1, min = min(currentData$Y), max = max(currentData$Y)))
return (c(bestX, bestY))
}
# Starting from startX and startY, hillClimb will move orthogonally (left, right, up, or down) to
# its best neighbor (or stay where it is if there is no better option). It will repeat this ticks
# number of times.
# Guesses: ticks
hillClimb <- function(currentData, ticks, startX = 1, startY = 1)
{
bestX = startX
bestY = startY
bestValue = currentData[currentData$X == bestX & currentData$Y == bestY,]$Value
for (i in 1:ticks)
{
xLower = bestX - 1
if (xLower == 0) xLower = max(currentData$X)
xUpper = bestX + 1
if (xUpper > max(currentData$X)) xUpper = 1
yLower = bestY - 1
if (yLower == 0) yLower = max(currentData$Y)
yUpper = bestY + 1
if (yUpper > max(currentData$Y)) yUpper = 1
if (currentData[currentData$X == xLower & currentData$Y == bestY,]$Value > bestValue)
{
bestX = xLower
bestValue = currentData[currentData$X == xLower & currentData$Y == bestY,]$Value
}
if (currentData[currentData$X == xUpper & currentData$Y == bestY,]$Value > bestValue)
{
bestX = xUpper
bestValue = currentData[currentData$X == xUpper & currentData$Y == bestY,]$Value
}
if (currentData[currentData$X == bestX & currentData$Y == yUpper,]$Value > bestValue)
{
bestY = yUpper
bestValue = currentData[currentData$X == bestX & currentData$Y == yUpper,]$Value
}
if (currentData[currentData$X == bestX & currentData$Y == yLower,]$Value > bestValue)
{
bestY = yLower
bestValue = currentData[currentData$X == bestX & currentData$Y == yLower,]$Value
}
}
return (c(bestX, bestY))
}
Accepts two additional parameters:
linearCoolingFunction <- function (x) { return(1 - 0.1 * x) }
powerCoolingFunction <- function (x) { return(0.75 ^ x) }
decayCoolingFunction <- function (x) { return(1 / (x + 1)) }
# Simulated annealing works very similarly to hillClimb(), save that simulated annealing has a chance to accept a bad step.
# This allows it to escape local optima. The coolingFunction, given a positive real number, returns a percent likelihood to
# accept a swap even if it is bad. The temperatureTickAmount determines how much to increment the input to coolingFunction
# each time it is called.
# Guesses: ticks
simulatedAnnealing <- function(currentData, ticks, startX = 1, startY = 1, coolingFunction = NULL, temperatureTickAmount = NULL)
{
if (is.null(coolingFunction))
{
stop("Simulated Annealing requires CoolingFunction!")
}
if (is.null(temperatureTickAmount))
{
stop("Simulated Annealing requires TemperatureTickAmount!")
}
bestX = startX
bestY = startY
bestValue = currentData[currentData$X == bestX & currentData$Y == bestY,]$Value
currentCoolingValue = 0
for (i in 1:ticks)
{
xLower = bestX - 1
if (xLower == 0) xLower = max(currentData$X)
xUpper = bestX + 1
if (xUpper > max(currentData$X)) xUpper = 1
yLower = bestY - 1
if (yLower == 0) yLower = max(currentData$Y)
yUpper = bestY + 1
if (yUpper > max(currentData$Y)) yUpper = 1
nextX = bestX
nextY = bestY
nextValue = 0
if (currentData[currentData$X == xLower & currentData$Y == bestY,]$Value > nextValue)
{
nextX = xLower
nextValue = currentData[currentData$X == xLower & currentData$Y == bestY,]$Value
}
if (currentData[currentData$X == xUpper & currentData$Y == bestY,]$Value > nextValue)
{
nextX = xUpper
nextValue = currentData[currentData$X == xUpper & currentData$Y == bestY,]$Value
}
if (currentData[currentData$X == bestX & currentData$Y == yUpper,]$Value > nextValue)
{
nextY = yUpper
nextValue = currentData[currentData$X == bestX & currentData$Y == yUpper,]$Value
}
if (currentData[currentData$X == bestX & currentData$Y == yLower,]$Value > nextValue)
{
nextY = yLower
nextValue = currentData[currentData$X == bestX & currentData$Y == yLower,]$Value
}
# The part that makes this simulated annealing, not hill climbing
currentCoolingValue = currentCoolingValue + temperatureTickAmount
if ((nextValue < bestValue && runif(1) < coolingFunction(currentCoolingValue)) ||
nextValue > bestValue)
{
bestValue = nextValue
bestX = nextX
bestY = nextY
}
}
return (c(bestX, bestY))
}
# Convert binary vectors to decimal integers, and vice versa
dtob <- function(number, bits = 4)
{
return (as.integer(rev(intToBits(number)[1:bits])))
}
btod <- function(bits)
{
return (sum(bits * 2^seq(0, length(bits) - 1, 1)))
}
# Given a list of two parents (binary vectors), returns a list of two children (also binary vectors)
getChildren <- function(parents)
{
splitPosition = floor(runif(1, min = 0, max = length(parents[[1]])))
child2 = c(parents[[2]][1:splitPosition], parents[[1]][(splitPosition+1):length(parents[[1]])])
child1 = c(parents[[1]][1:splitPosition], parents[[2]][(splitPosition+1):length(parents[[2]])])
return (list(child1, child2))
}
# For each gene in each binary vector in the population list, with mutationRate chance, toggles the gene.
mutate <- function(population, mutationRate = 0.05)
{
for (i in 1:length(population))
{
for (j in 1:length(population[[i]]))
{
if (runif(1, 0, 1) < mutationRate)
{
population[[i]][j] = 1 - population[[i]][j]
}
}
}
return (population)
}
# Given the current population, computes the best two to be the next generation's parents.
getNextParents <- function(population, currentData, xBits, yBits, xMin, yMin)
{
values = c()
for (i in 1:length(population))
{
values[i] = currentData[
currentData$X == btod(population[[i]][1:xBits])+xMin &
currentData$Y == btod(population[[i]][(xBits+1):(xBits+yBits)])+yMin,]$Value
}
return(rev(population[order(values)])[1:2])
}
# Uses a genetic algorithm to make iterative guesses. Always uses a population size of 4 (2 parents, 2 children).
# Guesses: 4 * ticks - 1
geneticAlgorithm <- function(currentData, ticks, startX = 1, startY = 1, mutationRate = 0.05)
{
xMin = min(currentData$X)
yMin = min(currentData$Y)
xRange = max(currentData$X) - xMin + 1
yRange = max(currentData$Y) - yMin + 1
xBits = ceiling(log2(xRange))
yBits = ceiling(log2(yRange))
randomParent = c(sample(c(0, 1), xBits, replace=TRUE), sample(c(0, 1), yBits, replace=TRUE))
baseParent = c(dtob(startX - xMin, xBits), dtob(startY - yMin, yBits))
parents = list(baseParent, randomParent)
for (i in 1:ticks)
{
children = getChildren(parents)
population = c(mutate(children), mutate(parents))
parents = getNextParents(population, currentData, xBits, yBits, xMin, yMin)
}
return (c(btod(parents[[1]][1:xBits])+xMin, btod(parents[[1]][(xBits+1):(xBits+yBits)])))
}
# Simulates a swarm of particles, each of which has inertia, and accelerates toward the best point it has seen
# and the best point that any particle has seen. Accounts for the world wrapping on both axes.
# Unlike other functions, completely disregards startX and startY.
# Guesses: particleCount * ticks
particleSwarm <- function(currentData, ticks, startX = 1, startY = 1, particleCount = 3, inertia = 0.9, personalAcceleration = 0.75, globalAcceleration = 0.75)
{
particles <- data.frame(X = 0, Y = 0, Vx = 0, Vy = 0, BestSeenX = 0, BestSeenY = 0)
globalBestX = 0
globalBestY = 0
globalBestValue = -Inf
xRange = max(currentData$X) - min(currentData$X) + 1
yRange = max(currentData$Y) - min(currentData$Y) + 1
for (i in 1:particleCount)
{
particles[i,]$X = floor(runif(1, min = min(currentData$X), max = max(currentData$X)))
particles[i,]$Y = floor(runif(1, min = min(currentData$Y), max = max(currentData$Y)))
particles[i,]$Vx = runif(1, -2, 2)
particles[i,]$Vy = runif(1, -2, 2)
particles[i,]$BestSeenX = particles[i,]$X
particles[i,]$BestSeenY = particles[i,]$Y
score = currentData[currentData$X == particles[i,]$X & currentData$Y == particles[i,]$Y,]$Value
if (score > globalBestValue)
{
globalBestX = particles[i,]$X
globalBestY = particles[i,]$Y
globalBestValue = score
}
}
# Loop to advance the particles
for (t in 1:ticks)
{
# Update variables
updated = c() # Used to streamline updating the global best
for (i in 1:particleCount)
{
# Update position
particles[i,]$X = round(particles[i,]$X + particles[i,]$Vx)
particles[i,]$Y = round(particles[i,]$Y + particles[i,]$Vy)
# Wrap position on both axes
while (particles[i,]$X < min(currentData$X))
{
particles[i,]$X = particles[i,]$X + xRange
}
while (particles[i,]$X > max(currentData$X))
{
particles[i,]$X = particles[i,]$X - xRange
}
while (particles[i,]$Y < min(currentData$Y))
{
particles[i,]$Y = particles[i,]$Y + yRange
}
while (particles[i,]$Y > max(currentData$Y))
{
particles[i,]$Y = particles[i,]$Y - yRange
}
# Update velocity
particles[i,]$Vx = inertia * particles[i,]$Vx +
personalAcceleration * (particles[i,]$BestSeenX - particles[i,]$X) +
globalAcceleration * (globalBestX - particles[i,]$X)
particles[i,]$Vy = inertia * particles[i,]$Vy +
personalAcceleration * (particles[i,]$BestSeenY - particles[i,]$Y) +
globalAcceleration * (globalBestY - particles[i,]$Y)
# Update personal best
prevScore = currentData[currentData$X == particles[i,]$BestSeenX & currentData$Y == particles[i,]$BestSeenY,]$Value
currScore = currentData[currentData$X == particles[i,]$X & currentData$Y == particles[i,]$Y,]$Value
updated[i] = FALSE
if (currScore > prevScore)
{
updated[i] = TRUE
particles[i,]$BestSeenX = particles[i,]$X
particles[i,]$BestSeenY = particles[i,]$Y
}
}
updatedParticles = particles[updated,]
# Update global best
if (sum(updated) > 0)
{
for (i in 1:nrow(updatedParticles))
{
score = currentData[currentData$X == updatedParticles[i,]$BestSeenX & currentData$Y == updatedParticles[i,]$BestSeenY,]$Value
if (score > globalBestValue)
{
globalBestValue = score
globalBestX = updatedParticles[i,]$BestSeenX
globalBestY = updatedParticles[i,]$BestSeenY
}
}
}
}
return (c(globalBestX, globalBestY))
}
You job is to optimize as much as possible on the function provided. You should definitely be able to get above 70%. Try to get above 90%. The following code chunk is provided as an example for how to use these functions. Remember to provide additional parameters as necessary. It is advised to not visualize the function unless strictly necessary - this can take over a minute. It will provide insight into how to tune your parameters, but may not be worth it for each iteration.
| Function | Additional parameters |
|---|---|
| randomGuesses | none |
| hillClimb | none |
| simulatedAnnealing | coolingFunction, temperatureTickAmount |
| geneticAlgorithm | mutationRate (0.05) |
| particleSwarm | particleCount (3), inertia (0.75), personalAcceleration (0.75), globalAcceleration (0.75) |
Bold parameters are required. Default values appear in parentheses after the corresponding parameter.
randomOutput = assembleData(timeWorld = timeWorld, optimizeFunction = randomGuesses, ticksPerTime = 1)
cat("Random guesses were, on average, off by", evaluate(randomOutput), "units.\n")
## Random guesses were, on average, off by 5.814806 units.
cat(sprintf("This is %.2f percent accurate.", evaluatePercent(randomOutput) * 100), "\n")
## This is 48.60 percent accurate.
cat(sprintf("This synced with optimal output %.2f percent of the time.", evaluateSyncCount(randomOutput) * 100), "\n")
## This synced with optimal output 3.85 percent of the time.
# visualizeFunction(randomOutput)
“Try again. Fail again. Fail better.” - Samuel Beckett